react-linear-feedback 0.1.1 β†’ 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ A trusted user opens it with `?feedback`, **drags a box** over the page, picks a
13
13
  - 🌍 **Works in any React app** β€” Next.js, Vite, Remix, CRA… (no `next` dependency; `"use client"` is built in)
14
14
  - πŸ–ΌοΈ **Annotated screenshots** via [`modern-screenshot`](https://github.com/qq15725/modern-screenshot) (handles Tailwind v4 / `oklch()`)
15
15
  - 🏷️ **Labels by name, self-healing** β€” resolved at request time, so recoloring/recreating a label in Linear won't break it; applied best-effort
16
- - πŸ”Œ **Tiny server core** + Next.js & Node/Express adapters
16
+ - πŸ”Œ **Tiny server core** + Next.js, Node/Express, and Vite-dev adapters
17
17
 
18
18
  ## Contents
19
19
 
@@ -23,11 +23,9 @@ A trusted user opens it with `?feedback`, **drags a box** over the page, picks a
23
23
 
24
24
  ```bash
25
25
  npm i react-linear-feedback
26
- # the server entry needs the Linear SDK (optional peer):
27
- npm i @linear/sdk
28
26
  ```
29
27
 
30
- `react` / `react-dom` are peer dependencies. `@linear/sdk` is an **optional** peer β€” only needed wherever you run the server handler. Do [Linear setup](#linear-setup) first to get your API key, team, and labels.
28
+ `react` / `react-dom` are peer dependencies. `@linear/sdk` ships as a dependency β€” it's imported only by the server entry, so it's tree-shaken out of client bundles. Do [Linear setup](#linear-setup) first to get your API key, team, and labels.
31
29
 
32
30
  ## Quick start (Next.js, App Router)
33
31
 
@@ -66,33 +64,73 @@ No `"use client"` needed β€” the package ships it, so you can mount `<FeedbackGa
66
64
 
67
65
  ## Use with Vite / any React app
68
66
 
69
- Mount the widget and point it at your backend:
67
+ Unlike Next.js, a Vite SPA has **no server of its own** β€” so the handler runs as a serverless function (e.g. on Vercel) in production, and as a dev-server plugin locally. Same widget either way.
68
+
69
+ **1. Mount the widget** once, in your root component:
70
70
 
71
71
  ```tsx
72
72
  import { FeedbackGate } from "react-linear-feedback/react";
73
73
 
74
- <FeedbackGate endpoint="https://api.example.com/feedback" brandColor="#7f56d9" />
74
+ <FeedbackGate brandColor="#7f56d9" />; // endpoint defaults to /api/feedback (same origin)
75
+ ```
76
+
77
+ **2. Production β€” a Vercel serverless function** at `api/feedback.ts`:
78
+
79
+ ```ts
80
+ import { createNodeHandler, cookieGate } from "react-linear-feedback/server";
81
+
82
+ // Node runtime (the default for /api functions) β€” Edge has no Buffer for the screenshot upload.
83
+ export default createNodeHandler({
84
+ apiKey: process.env.LINEAR_API_KEY!,
85
+ teamId: process.env.LINEAR_TEAM_ID!,
86
+ authorize: cookieGate("wh_feedback"),
87
+ });
88
+ ```
89
+
90
+ Set `LINEAR_API_KEY` / `LINEAR_TEAM_ID` in your Vercel project's env (server-side β€” **not** `VITE_`-prefixed, so they never reach the bundle). The function is same-origin as the SPA, so no CORS needed.
91
+
92
+ **3. Local dev β€” the Vite plugin**, so `vite dev` serves the same endpoint (without it, `POST /api/feedback` 404s locally):
93
+
94
+ ```ts
95
+ // vite.config.ts
96
+ import { defineConfig, loadEnv } from "vite";
97
+ import { linearFeedback } from "react-linear-feedback/vite";
98
+ import { cookieGate } from "react-linear-feedback/server";
99
+
100
+ export default defineConfig(({ mode }) => {
101
+ const env = loadEnv(mode, process.cwd(), ""); // reads .env (LINEAR_* are server-side, un-prefixed)
102
+ return {
103
+ plugins: [
104
+ linearFeedback({
105
+ apiKey: env.LINEAR_API_KEY,
106
+ teamId: env.LINEAR_TEAM_ID,
107
+ authorize: cookieGate("wh_feedback"),
108
+ }),
109
+ ],
110
+ };
111
+ });
75
112
  ```
76
113
 
77
- Run the handler on any Node server (Express shown):
114
+ The plugin is dev-only (`apply: "serve"`) β€” it has no effect on the production build.
115
+
116
+ ### Other Node servers (Express, Hono, …)
117
+
118
+ `createNodeHandler` is a plain `(req, res)` handler that reads the raw body itself (works with or without `express.json()`):
78
119
 
79
120
  ```ts
80
121
  import express from "express";
81
- import cors from "cors";
82
122
  import { createNodeHandler, cookieGate } from "react-linear-feedback/server";
83
123
 
84
124
  const app = express();
85
- // If the app and API are on different origins, CORS is required or the fetch is blocked:
86
- app.use("/feedback", cors({ origin: "https://your-site.com", credentials: true }));
87
- app.post("/feedback", createNodeHandler({
125
+ app.post("/api/feedback", createNodeHandler({
88
126
  apiKey: process.env.LINEAR_API_KEY!,
89
127
  teamId: process.env.LINEAR_TEAM_ID!,
90
- authorize: cookieGate("wh_feedback"), // optional β€” omit to leave the endpoint open
128
+ authorize: cookieGate("wh_feedback"),
91
129
  }));
92
130
  app.listen(8787);
93
131
  ```
94
132
 
95
- The handler reads the raw body itself, so it works with or without `express.json()`.
133
+ If the SPA and API are on **different origins**, set `allowedOrigin: "https://your-site.com"` and enable CORS (`credentials: true` so the cookie is sent).
96
134
 
97
135
  ## Linear setup
98
136
 
@@ -151,7 +189,7 @@ createNextRoute({
151
189
 
152
190
  ## Theming
153
191
 
154
- Set `brandColor`, or override any CSS variable on `.lfb-doc-layer, .lfb-fixed-layer`:
192
+ Set `brandColor`, or override any CSS variable on `.lfb-root`:
155
193
  `--lfb-brand`, `--lfb-fg`, `--lfb-surface`, `--lfb-border`, `--lfb-radius`, `--lfb-rect`, `--lfb-z`, `--lfb-font`.
156
194
 
157
195
  ## Custom types
@@ -184,6 +222,7 @@ Submissions never throw in the UI β€” failures are logged to the browser console
184
222
  - `endpoint` points at your route, and `LINEAR_API_KEY` / `LINEAR_TEAM_ID` are set.
185
223
  - **CORS** is configured when the app and API are on different origins.
186
224
  - `runtime = "nodejs"` is set on the Next.js route (Edge has no `Buffer`).
225
+ - On a **Vite SPA**, `POST /api/feedback` 404s under `vite dev` unless you add the [`linearFeedback` Vite plugin](#use-with-vite--any-react-app) (or run `vercel dev`). In production it's served by your deployed function.
187
226
  - The expected Linear **labels exist** (otherwise the issue is created without a label, with a warning).
188
227
 
189
228
  ## License
@@ -169,7 +169,13 @@ var TYPE_ICONS = {
169
169
  // src/react/styles.ts
170
170
  var STYLE_ID = "lfb-styles";
171
171
  var CSS2 = `
172
- .lfb-doc-layer, .lfb-fixed-layer {
172
+ /*
173
+ * Defaults live on .lfb-root (the element that also receives the inline brandColor
174
+ * override) \u2014 NOT on the layers. The layers/FAB inherit from here. If the defaults
175
+ * sat on .lfb-doc-layer/.lfb-fixed-layer, a direct rule on those elements would beat
176
+ * the brandColor inherited from .lfb-root, so the \`brandColor\` prop would never apply.
177
+ */
178
+ .lfb-root {
173
179
  --lfb-brand: #6366f1;
174
180
  --lfb-fg: #181d27;
175
181
  --lfb-fg-secondary: #414651;
@@ -250,12 +256,24 @@ var CSS2 = `
250
256
  .lfb-stack--bottom-left { right: auto; left: 16px; align-items: flex-start; }
251
257
  .lfb-stack--top-right { bottom: auto; top: 16px; }
252
258
  .lfb-stack--top-left { bottom: auto; top: 16px; right: auto; left: 16px; align-items: flex-start; }
259
+ /* Edge tabs: anchored flush to a side, vertically centered. */
260
+ .lfb-stack--right { top: 50%; bottom: auto; right: 0; transform: translateY(-50%); align-items: flex-end; }
261
+ .lfb-stack--left { top: 50%; bottom: auto; left: 0; right: auto; transform: translateY(-50%); align-items: flex-start; }
262
+ /* Keep the transient name-prompt / sent-toast cards off the viewport edge (the tab stays flush). */
263
+ .lfb-stack--right > .lfb-card { margin-right: 12px; }
264
+ .lfb-stack--left > .lfb-card { margin-left: 12px; }
253
265
 
254
266
  .lfb-fab { display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9999px; padding: 12px 16px; font-size: 14px; font-weight: 600; font-family: var(--lfb-font); cursor: pointer; background: var(--lfb-brand); color: #fff; box-shadow: 0 10px 25px rgba(0,0,0,0.18); transition: transform 0.1s, background 0.1s; }
255
267
  .lfb-fab:hover { transform: scale(1.05); background: color-mix(in srgb, var(--lfb-brand) 88%, black); }
256
268
  .lfb-fab--active { background: var(--lfb-surface); color: var(--lfb-fg); border: 1px solid var(--lfb-border); }
257
269
  .lfb-fab--active:hover { background: var(--lfb-surface-hover); }
258
270
 
271
+ /* Edge-tab launcher: compact, icon-only, rounded on the inner side only, flush to the viewport edge. */
272
+ .lfb-fab--tab { gap: 0; padding: 14px 12px; border-radius: 12px 0 0 12px; box-shadow: -6px 0 20px rgba(0,0,0,0.18); }
273
+ .lfb-fab--tab:hover { transform: translateX(-2px); }
274
+ .lfb-stack--left .lfb-fab--tab { border-radius: 0 12px 12px 0; box-shadow: 6px 0 20px rgba(0,0,0,0.18); }
275
+ .lfb-stack--left .lfb-fab--tab:hover { transform: translateX(2px); }
276
+
259
277
  .lfb-toast { display: flex; align-items: flex-start; gap: 10px; width: 300px; max-width: calc(100vw - 32px); }
260
278
  .lfb-toast-icon { color: #17b26a; flex-shrink: 0; margin-top: 1px; }
261
279
  .lfb-toast-body { min-width: 0; flex: 1; }
@@ -287,6 +305,7 @@ function FeedbackWidget({
287
305
  nameStorageKey = "wh_feedback_name",
288
306
  fabLabel = "Give feedback"
289
307
  }) {
308
+ const isEdge = position === "right" || position === "left";
290
309
  const [mode, setMode] = react.useState({ kind: "idle" });
291
310
  const [name, setName] = react.useState("");
292
311
  const [nameDraft, setNameDraft] = react.useState("");
@@ -563,21 +582,18 @@ function FeedbackWidget({
563
582
  ] }),
564
583
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Dismiss", onClick: () => setResult(null), children: /* @__PURE__ */ jsxRuntime.jsx(XIcon, {}) })
565
584
  ] }),
566
- /* @__PURE__ */ jsxRuntime.jsx(
585
+ /* @__PURE__ */ jsxRuntime.jsxs(
567
586
  "button",
568
587
  {
569
588
  type: "button",
570
- className: `lfb-fab${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
589
+ className: `lfb-fab${isEdge ? " lfb-fab--tab" : ""}${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
571
590
  "aria-label": mode.kind === "idle" ? fabLabel : "Cancel feedback",
591
+ title: isEdge ? mode.kind === "idle" ? fabLabel : "Cancel feedback" : void 0,
572
592
  onClick: startFlow,
573
- children: mode.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
574
- /* @__PURE__ */ jsxRuntime.jsx(MessageIcon, { size: 18 }),
575
- " ",
576
- fabLabel
577
- ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
578
- /* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: 18 }),
579
- " Cancel"
580
- ] })
593
+ children: [
594
+ mode.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsx(MessageIcon, { size: isEdge ? 22 : 18 }) : /* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: isEdge ? 22 : 18 }),
595
+ !isEdge && /* @__PURE__ */ jsxRuntime.jsx("span", { children: mode.kind === "idle" ? fabLabel : "Cancel" })
596
+ ]
581
597
  }
582
598
  )
583
599
  ] })
@@ -66,7 +66,9 @@ type FeedbackResult = {
66
66
  };
67
67
 
68
68
  declare const DEFAULT_TYPES: FeedbackTypeOption[];
69
- type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
69
+ type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
70
+ /** Edge tabs: compact, icon-only launcher flush to the side, vertically centered. */
71
+ | "right" | "left";
70
72
  type FeedbackWidgetProps = {
71
73
  /** Endpoint that runs the server handler (default "/api/feedback"). */
72
74
  endpoint?: string;
@@ -66,7 +66,9 @@ type FeedbackResult = {
66
66
  };
67
67
 
68
68
  declare const DEFAULT_TYPES: FeedbackTypeOption[];
69
- type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
69
+ type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
70
+ /** Edge tabs: compact, icon-only launcher flush to the side, vertically centered. */
71
+ | "right" | "left";
70
72
  type FeedbackWidgetProps = {
71
73
  /** Endpoint that runs the server handler (default "/api/feedback"). */
72
74
  endpoint?: string;
@@ -167,7 +167,13 @@ var TYPE_ICONS = {
167
167
  // src/react/styles.ts
168
168
  var STYLE_ID = "lfb-styles";
169
169
  var CSS2 = `
170
- .lfb-doc-layer, .lfb-fixed-layer {
170
+ /*
171
+ * Defaults live on .lfb-root (the element that also receives the inline brandColor
172
+ * override) \u2014 NOT on the layers. The layers/FAB inherit from here. If the defaults
173
+ * sat on .lfb-doc-layer/.lfb-fixed-layer, a direct rule on those elements would beat
174
+ * the brandColor inherited from .lfb-root, so the \`brandColor\` prop would never apply.
175
+ */
176
+ .lfb-root {
171
177
  --lfb-brand: #6366f1;
172
178
  --lfb-fg: #181d27;
173
179
  --lfb-fg-secondary: #414651;
@@ -248,12 +254,24 @@ var CSS2 = `
248
254
  .lfb-stack--bottom-left { right: auto; left: 16px; align-items: flex-start; }
249
255
  .lfb-stack--top-right { bottom: auto; top: 16px; }
250
256
  .lfb-stack--top-left { bottom: auto; top: 16px; right: auto; left: 16px; align-items: flex-start; }
257
+ /* Edge tabs: anchored flush to a side, vertically centered. */
258
+ .lfb-stack--right { top: 50%; bottom: auto; right: 0; transform: translateY(-50%); align-items: flex-end; }
259
+ .lfb-stack--left { top: 50%; bottom: auto; left: 0; right: auto; transform: translateY(-50%); align-items: flex-start; }
260
+ /* Keep the transient name-prompt / sent-toast cards off the viewport edge (the tab stays flush). */
261
+ .lfb-stack--right > .lfb-card { margin-right: 12px; }
262
+ .lfb-stack--left > .lfb-card { margin-left: 12px; }
251
263
 
252
264
  .lfb-fab { display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9999px; padding: 12px 16px; font-size: 14px; font-weight: 600; font-family: var(--lfb-font); cursor: pointer; background: var(--lfb-brand); color: #fff; box-shadow: 0 10px 25px rgba(0,0,0,0.18); transition: transform 0.1s, background 0.1s; }
253
265
  .lfb-fab:hover { transform: scale(1.05); background: color-mix(in srgb, var(--lfb-brand) 88%, black); }
254
266
  .lfb-fab--active { background: var(--lfb-surface); color: var(--lfb-fg); border: 1px solid var(--lfb-border); }
255
267
  .lfb-fab--active:hover { background: var(--lfb-surface-hover); }
256
268
 
269
+ /* Edge-tab launcher: compact, icon-only, rounded on the inner side only, flush to the viewport edge. */
270
+ .lfb-fab--tab { gap: 0; padding: 14px 12px; border-radius: 12px 0 0 12px; box-shadow: -6px 0 20px rgba(0,0,0,0.18); }
271
+ .lfb-fab--tab:hover { transform: translateX(-2px); }
272
+ .lfb-stack--left .lfb-fab--tab { border-radius: 0 12px 12px 0; box-shadow: 6px 0 20px rgba(0,0,0,0.18); }
273
+ .lfb-stack--left .lfb-fab--tab:hover { transform: translateX(2px); }
274
+
257
275
  .lfb-toast { display: flex; align-items: flex-start; gap: 10px; width: 300px; max-width: calc(100vw - 32px); }
258
276
  .lfb-toast-icon { color: #17b26a; flex-shrink: 0; margin-top: 1px; }
259
277
  .lfb-toast-body { min-width: 0; flex: 1; }
@@ -285,6 +303,7 @@ function FeedbackWidget({
285
303
  nameStorageKey = "wh_feedback_name",
286
304
  fabLabel = "Give feedback"
287
305
  }) {
306
+ const isEdge = position === "right" || position === "left";
288
307
  const [mode, setMode] = useState({ kind: "idle" });
289
308
  const [name, setName] = useState("");
290
309
  const [nameDraft, setNameDraft] = useState("");
@@ -561,21 +580,18 @@ function FeedbackWidget({
561
580
  ] }),
562
581
  /* @__PURE__ */ jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Dismiss", onClick: () => setResult(null), children: /* @__PURE__ */ jsx(XIcon, {}) })
563
582
  ] }),
564
- /* @__PURE__ */ jsx(
583
+ /* @__PURE__ */ jsxs(
565
584
  "button",
566
585
  {
567
586
  type: "button",
568
- className: `lfb-fab${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
587
+ className: `lfb-fab${isEdge ? " lfb-fab--tab" : ""}${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
569
588
  "aria-label": mode.kind === "idle" ? fabLabel : "Cancel feedback",
589
+ title: isEdge ? mode.kind === "idle" ? fabLabel : "Cancel feedback" : void 0,
570
590
  onClick: startFlow,
571
- children: mode.kind === "idle" ? /* @__PURE__ */ jsxs(Fragment, { children: [
572
- /* @__PURE__ */ jsx(MessageIcon, { size: 18 }),
573
- " ",
574
- fabLabel
575
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
576
- /* @__PURE__ */ jsx(XIcon, { size: 18 }),
577
- " Cancel"
578
- ] })
591
+ children: [
592
+ mode.kind === "idle" ? /* @__PURE__ */ jsx(MessageIcon, { size: isEdge ? 22 : 18 }) : /* @__PURE__ */ jsx(XIcon, { size: isEdge ? 22 : 18 }),
593
+ !isEdge && /* @__PURE__ */ jsx("span", { children: mode.kind === "idle" ? fabLabel : "Cancel" })
594
+ ]
579
595
  }
580
596
  )
581
597
  ] })
@@ -121,7 +121,8 @@ function createNextRoute(config) {
121
121
  }
122
122
  function cookieGate(name, value = "1") {
123
123
  return (req) => {
124
- const cookie = req.headers.get("cookie") ?? "";
124
+ const { headers } = req;
125
+ const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
125
126
  return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
126
127
  };
127
128
  }
@@ -70,8 +70,16 @@ type NextRouteConfig = FeedbackServerConfig & {
70
70
  authorize?: (req: Request) => boolean | Promise<boolean>;
71
71
  };
72
72
  declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
73
- /** Authorize helper: allow only requests carrying `name=value` in the Cookie header. */
74
- declare function cookieGate(name: string, value?: string): (req: Request) => boolean;
73
+ /**
74
+ * Authorize helper: allow only requests carrying `name=value` in the Cookie header.
75
+ *
76
+ * Works with BOTH a Web `Request` (Next.js App Router via `createNextRoute`) and a Node
77
+ * `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
78
+ * headers differently β€” `headers.get("cookie")` vs the plain `headers.cookie` string β€” so
79
+ * we feature-detect instead of assuming one shape. (Previously this only handled the Web
80
+ * `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
81
+ */
82
+ declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
75
83
 
76
84
  type NodeHandlerConfig = FeedbackServerConfig & {
77
85
  allowedOrigin?: string;
@@ -70,8 +70,16 @@ type NextRouteConfig = FeedbackServerConfig & {
70
70
  authorize?: (req: Request) => boolean | Promise<boolean>;
71
71
  };
72
72
  declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
73
- /** Authorize helper: allow only requests carrying `name=value` in the Cookie header. */
74
- declare function cookieGate(name: string, value?: string): (req: Request) => boolean;
73
+ /**
74
+ * Authorize helper: allow only requests carrying `name=value` in the Cookie header.
75
+ *
76
+ * Works with BOTH a Web `Request` (Next.js App Router via `createNextRoute`) and a Node
77
+ * `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
78
+ * headers differently β€” `headers.get("cookie")` vs the plain `headers.cookie` string β€” so
79
+ * we feature-detect instead of assuming one shape. (Previously this only handled the Web
80
+ * `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
81
+ */
82
+ declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
75
83
 
76
84
  type NodeHandlerConfig = FeedbackServerConfig & {
77
85
  allowedOrigin?: string;
@@ -119,7 +119,8 @@ function createNextRoute(config) {
119
119
  }
120
120
  function cookieGate(name, value = "1") {
121
121
  return (req) => {
122
- const cookie = req.headers.get("cookie") ?? "";
122
+ const { headers } = req;
123
+ const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
123
124
  return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
124
125
  };
125
126
  }
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ var sdk = require('@linear/sdk');
4
+
5
+ // src/server/core.ts
6
+ var MAX_NOTE = 5e3;
7
+ async function createFeedbackIssue(payload, config) {
8
+ const { apiKey, teamId } = config;
9
+ if (!apiKey || !teamId) throw new Error("not_configured");
10
+ const note = payload?.annotation?.note?.trim();
11
+ if (!note) throw new Error("note_required");
12
+ if (note.length > MAX_NOTE) throw new Error("note_too_long");
13
+ const { annotation, context } = payload;
14
+ const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
15
+ const linear = new sdk.LinearClient({ apiKey });
16
+ let assetUrl = null;
17
+ if (payload.screenshot?.startsWith("data:image/")) {
18
+ try {
19
+ assetUrl = await uploadScreenshot(linear, payload.screenshot);
20
+ } catch (err) {
21
+ console.error("[feedback] screenshot upload failed", err);
22
+ }
23
+ }
24
+ const description = [
25
+ note,
26
+ "",
27
+ "---",
28
+ assetUrl ? `![screenshot](${encodeURI(assetUrl)})` : "_No screenshot captured._",
29
+ "",
30
+ "**Context**",
31
+ `- Type: ${typeLabel}`,
32
+ context?.url ? `- Page: ${context.url}` : null,
33
+ annotation.name ? `- Reported by: ${annotation.name}` : null,
34
+ context?.userAgent ? `- User agent: ${context.userAgent}` : null,
35
+ context?.timestamp ? `- Submitted: ${context.timestamp}` : null
36
+ ].filter(Boolean).join("\n");
37
+ const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
38
+ const labelName = config.labels?.[annotation.type] ?? annotation.type;
39
+ const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
40
+ if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
41
+ const create = async (ids) => {
42
+ const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
43
+ return await created.issue;
44
+ };
45
+ const issue = await create(labelId ? [labelId] : []).catch((err) => {
46
+ if (!labelId) throw err;
47
+ console.warn("[feedback] create failed with label, retrying without it", err);
48
+ return create([]);
49
+ });
50
+ return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
51
+ }
52
+ function capitalize(s) {
53
+ return s.charAt(0).toUpperCase() + s.slice(1);
54
+ }
55
+ async function resolveLabelId(linear, name, teamId) {
56
+ try {
57
+ const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
58
+ if (nodes.length === 0) return null;
59
+ if (nodes.length === 1) return nodes[0].id;
60
+ const scored = await Promise.all(
61
+ nodes.map(async (n) => {
62
+ try {
63
+ const team = await n.team;
64
+ return { id: n.id, teamId: team?.id ?? null };
65
+ } catch {
66
+ return { id: n.id, teamId: null };
67
+ }
68
+ })
69
+ );
70
+ const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
71
+ return pick.id;
72
+ } catch (err) {
73
+ console.warn("[feedback] label lookup failed", name, err);
74
+ return null;
75
+ }
76
+ }
77
+ async function uploadScreenshot(linear, dataUrl) {
78
+ const [meta, b64] = dataUrl.split(",");
79
+ const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
80
+ const bytes = Buffer.from(b64, "base64");
81
+ const filename = `feedback-${Date.now()}.jpg`;
82
+ const upload = await linear.fileUpload(contentType, filename, bytes.length);
83
+ if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
84
+ const headers = new Headers();
85
+ headers.set("Content-Type", contentType);
86
+ headers.set("Cache-Control", "public, max-age=31536000");
87
+ upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
88
+ const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
89
+ if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
90
+ return upload.uploadFile.assetUrl;
91
+ }
92
+
93
+ // src/server/node.ts
94
+ var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
95
+ function send(res, status, body) {
96
+ res.statusCode = status;
97
+ res.setHeader("content-type", "application/json");
98
+ res.end(JSON.stringify(body));
99
+ }
100
+ async function readJson(req) {
101
+ const chunks = [];
102
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
103
+ const raw = Buffer.concat(chunks).toString("utf8");
104
+ if (!raw) throw new Error("bad_json");
105
+ try {
106
+ return JSON.parse(raw);
107
+ } catch {
108
+ throw new Error("bad_json");
109
+ }
110
+ }
111
+ function createNodeHandler(config) {
112
+ return async function handler(req, res) {
113
+ try {
114
+ if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
115
+ if (config.allowedOrigin) {
116
+ const origin = req.headers.origin ?? "";
117
+ if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
118
+ }
119
+ const payload = req.body ?? await readJson(req);
120
+ const issue = await createFeedbackIssue(payload, config);
121
+ send(res, 200, { ok: true, ...issue });
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
125
+ console.error("[feedback] issue create failed", err);
126
+ send(res, 502, { error: "issue_create_failed", message });
127
+ }
128
+ };
129
+ }
130
+
131
+ // src/vite/index.ts
132
+ function linearFeedback(config) {
133
+ const endpoint = config.endpoint ?? "/api/feedback";
134
+ const handler = createNodeHandler(config);
135
+ return {
136
+ name: "react-linear-feedback",
137
+ apply: "serve",
138
+ configureServer(server) {
139
+ server.middlewares.use(endpoint, (req, res, next) => {
140
+ if (req.method !== "POST") return next();
141
+ handler(req, res).catch(next);
142
+ });
143
+ }
144
+ };
145
+ }
146
+
147
+ exports.linearFeedback = linearFeedback;
@@ -0,0 +1,27 @@
1
+ import { Plugin } from 'vite';
2
+ import { IncomingMessage } from 'node:http';
3
+
4
+ type FeedbackServerConfig = {
5
+ /** Linear personal API key (server-side secret). */
6
+ apiKey: string;
7
+ /** Target team UUID. */
8
+ teamId: string;
9
+ /**
10
+ * Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
11
+ * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
+ */
13
+ labels?: Record<string, string>;
14
+ };
15
+
16
+ type NodeHandlerConfig = FeedbackServerConfig & {
17
+ allowedOrigin?: string;
18
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
19
+ };
20
+
21
+ type LinearFeedbackViteConfig = NodeHandlerConfig & {
22
+ /** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
23
+ endpoint?: string;
24
+ };
25
+ declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
26
+
27
+ export { type LinearFeedbackViteConfig, linearFeedback };
@@ -0,0 +1,27 @@
1
+ import { Plugin } from 'vite';
2
+ import { IncomingMessage } from 'node:http';
3
+
4
+ type FeedbackServerConfig = {
5
+ /** Linear personal API key (server-side secret). */
6
+ apiKey: string;
7
+ /** Target team UUID. */
8
+ teamId: string;
9
+ /**
10
+ * Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
11
+ * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
+ */
13
+ labels?: Record<string, string>;
14
+ };
15
+
16
+ type NodeHandlerConfig = FeedbackServerConfig & {
17
+ allowedOrigin?: string;
18
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
19
+ };
20
+
21
+ type LinearFeedbackViteConfig = NodeHandlerConfig & {
22
+ /** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
23
+ endpoint?: string;
24
+ };
25
+ declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
26
+
27
+ export { type LinearFeedbackViteConfig, linearFeedback };
@@ -0,0 +1,145 @@
1
+ import { LinearClient } from '@linear/sdk';
2
+
3
+ // src/server/core.ts
4
+ var MAX_NOTE = 5e3;
5
+ async function createFeedbackIssue(payload, config) {
6
+ const { apiKey, teamId } = config;
7
+ if (!apiKey || !teamId) throw new Error("not_configured");
8
+ const note = payload?.annotation?.note?.trim();
9
+ if (!note) throw new Error("note_required");
10
+ if (note.length > MAX_NOTE) throw new Error("note_too_long");
11
+ const { annotation, context } = payload;
12
+ const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
13
+ const linear = new LinearClient({ apiKey });
14
+ let assetUrl = null;
15
+ if (payload.screenshot?.startsWith("data:image/")) {
16
+ try {
17
+ assetUrl = await uploadScreenshot(linear, payload.screenshot);
18
+ } catch (err) {
19
+ console.error("[feedback] screenshot upload failed", err);
20
+ }
21
+ }
22
+ const description = [
23
+ note,
24
+ "",
25
+ "---",
26
+ assetUrl ? `![screenshot](${encodeURI(assetUrl)})` : "_No screenshot captured._",
27
+ "",
28
+ "**Context**",
29
+ `- Type: ${typeLabel}`,
30
+ context?.url ? `- Page: ${context.url}` : null,
31
+ annotation.name ? `- Reported by: ${annotation.name}` : null,
32
+ context?.userAgent ? `- User agent: ${context.userAgent}` : null,
33
+ context?.timestamp ? `- Submitted: ${context.timestamp}` : null
34
+ ].filter(Boolean).join("\n");
35
+ const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
36
+ const labelName = config.labels?.[annotation.type] ?? annotation.type;
37
+ const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
38
+ if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
39
+ const create = async (ids) => {
40
+ const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
41
+ return await created.issue;
42
+ };
43
+ const issue = await create(labelId ? [labelId] : []).catch((err) => {
44
+ if (!labelId) throw err;
45
+ console.warn("[feedback] create failed with label, retrying without it", err);
46
+ return create([]);
47
+ });
48
+ return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
49
+ }
50
+ function capitalize(s) {
51
+ return s.charAt(0).toUpperCase() + s.slice(1);
52
+ }
53
+ async function resolveLabelId(linear, name, teamId) {
54
+ try {
55
+ const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
56
+ if (nodes.length === 0) return null;
57
+ if (nodes.length === 1) return nodes[0].id;
58
+ const scored = await Promise.all(
59
+ nodes.map(async (n) => {
60
+ try {
61
+ const team = await n.team;
62
+ return { id: n.id, teamId: team?.id ?? null };
63
+ } catch {
64
+ return { id: n.id, teamId: null };
65
+ }
66
+ })
67
+ );
68
+ const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
69
+ return pick.id;
70
+ } catch (err) {
71
+ console.warn("[feedback] label lookup failed", name, err);
72
+ return null;
73
+ }
74
+ }
75
+ async function uploadScreenshot(linear, dataUrl) {
76
+ const [meta, b64] = dataUrl.split(",");
77
+ const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
78
+ const bytes = Buffer.from(b64, "base64");
79
+ const filename = `feedback-${Date.now()}.jpg`;
80
+ const upload = await linear.fileUpload(contentType, filename, bytes.length);
81
+ if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
82
+ const headers = new Headers();
83
+ headers.set("Content-Type", contentType);
84
+ headers.set("Cache-Control", "public, max-age=31536000");
85
+ upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
86
+ const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
87
+ if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
88
+ return upload.uploadFile.assetUrl;
89
+ }
90
+
91
+ // src/server/node.ts
92
+ var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
93
+ function send(res, status, body) {
94
+ res.statusCode = status;
95
+ res.setHeader("content-type", "application/json");
96
+ res.end(JSON.stringify(body));
97
+ }
98
+ async function readJson(req) {
99
+ const chunks = [];
100
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
101
+ const raw = Buffer.concat(chunks).toString("utf8");
102
+ if (!raw) throw new Error("bad_json");
103
+ try {
104
+ return JSON.parse(raw);
105
+ } catch {
106
+ throw new Error("bad_json");
107
+ }
108
+ }
109
+ function createNodeHandler(config) {
110
+ return async function handler(req, res) {
111
+ try {
112
+ if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
113
+ if (config.allowedOrigin) {
114
+ const origin = req.headers.origin ?? "";
115
+ if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
116
+ }
117
+ const payload = req.body ?? await readJson(req);
118
+ const issue = await createFeedbackIssue(payload, config);
119
+ send(res, 200, { ok: true, ...issue });
120
+ } catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err);
122
+ if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
123
+ console.error("[feedback] issue create failed", err);
124
+ send(res, 502, { error: "issue_create_failed", message });
125
+ }
126
+ };
127
+ }
128
+
129
+ // src/vite/index.ts
130
+ function linearFeedback(config) {
131
+ const endpoint = config.endpoint ?? "/api/feedback";
132
+ const handler = createNodeHandler(config);
133
+ return {
134
+ name: "react-linear-feedback",
135
+ apply: "serve",
136
+ configureServer(server) {
137
+ server.middlewares.use(endpoint, (req, res, next) => {
138
+ if (req.method !== "POST") return next();
139
+ handler(req, res).catch(next);
140
+ });
141
+ }
142
+ };
143
+ }
144
+
145
+ export { linearFeedback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-linear-feedback",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Drop-in React feedback widget: draw a box, write a note, and it captures a screenshot and opens a Linear issue. Framework-agnostic, self-contained styles, zero design-system dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Oliver Odgaard",
@@ -40,6 +40,11 @@
40
40
  "types": "./dist/server/index.d.ts",
41
41
  "import": "./dist/server/index.js",
42
42
  "require": "./dist/server/index.cjs"
43
+ },
44
+ "./vite": {
45
+ "types": "./dist/vite/index.d.ts",
46
+ "import": "./dist/vite/index.js",
47
+ "require": "./dist/vite/index.cjs"
43
48
  }
44
49
  },
45
50
  "scripts": {
@@ -48,29 +53,30 @@
48
53
  "prepublishOnly": "npm run build"
49
54
  },
50
55
  "dependencies": {
56
+ "@linear/sdk": "^86.0.0",
51
57
  "modern-screenshot": "^4.7.0"
52
58
  },
53
59
  "peerDependencies": {
54
- "@linear/sdk": ">=40",
55
60
  "react": ">=18",
56
- "react-dom": ">=18"
61
+ "react-dom": ">=18",
62
+ "vite": ">=5"
57
63
  },
58
64
  "peerDependenciesMeta": {
59
- "@linear/sdk": {
65
+ "react-dom": {
60
66
  "optional": true
61
67
  },
62
- "react-dom": {
68
+ "vite": {
63
69
  "optional": true
64
70
  }
65
71
  },
66
72
  "devDependencies": {
67
- "@linear/sdk": "^86.0.0",
68
73
  "@types/node": "^22.0.0",
69
74
  "@types/react": "^19.0.0",
70
75
  "@types/react-dom": "^19.0.0",
71
76
  "react": "^19.0.0",
72
77
  "react-dom": "^19.0.0",
73
78
  "tsup": "^8.5.0",
74
- "typescript": "^5.7.0"
79
+ "typescript": "^5.7.0",
80
+ "vite": "^6.0.0"
75
81
  }
76
82
  }